Вичерпний посібник з дженериків у TypeScript, що охоплює синтаксис, переваги, розширене використання та найкращі практики для роботи зі складними типами даних у глобальній розробці програмного забезпечення.
Дженерики в TypeScript: Опановуємо складні типи даних для надійних застосунків
TypeScript, надмножина JavaScript, дає змогу розробникам писати більш надійний та підтримуваний код завдяки статичній типізації. Однією з його найпотужніших функцій є дженерики, які дозволяють писати код, що може працювати з різними типами даних, зберігаючи при цьому типову безпеку. Цей посібник пропонує вичерпне дослідження дженериків у TypeScript, зосереджуючись на їх застосуванні до складних типів даних у контексті глобальної розробки програмного забезпечення.
Що таке дженерики?
Дженерики надають спосіб написання коду для повторного використання, який може працювати з різними типами. Замість того, щоб писати окремі функції або класи для кожного типу, який ви хочете підтримувати, ви можете написати єдину функцію або клас, що використовує параметри типів. Ці параметри типів є заповнювачами для фактичних типів, які будуть використані під час виклику або створення екземпляра функції чи класу. Це особливо корисно при роботі зі складними структурами даних, де тип даних у цих структурах може змінюватися.
Переваги використання дженериків
- Повторне використання коду: Пишіть код один раз і використовуйте його з різними типами. Це зменшує дублювання коду та робить вашу кодову базу більш підтримуваною.
- Типова безпека: Дженерики дозволяють компілятору TypeScript забезпечувати типову безпеку на етапі компіляції. Це допомагає запобігти помилкам виконання, пов'язаним з невідповідністю типів.
- Покращена читабельність: Дженерики роблять ваш код більш читабельним, чітко вказуючи типи, з якими призначені працювати ваші функції та класи.
- Підвищена продуктивність: У деяких випадках дженерики можуть призвести до покращення продуктивності, оскільки компілятор може оптимізувати згенерований код на основі конкретних використовуваних типів.
Базовий синтаксис дженериків
Базовий синтаксис дженериків включає використання кутових дужок (< >) для оголошення параметрів типу. Ці параметри типу зазвичай називаються T
, K
, V
тощо, але ви можете використовувати будь-який дійсний ідентифікатор. Ось простий приклад дженерик-функції:
function identity<T>(arg: T): T {
return arg;
}
let myString: string = identity<string>("hello");
let myNumber: number = identity<number>(123);
let myBoolean: boolean = identity<boolean>(true);
console.log(myString); // Вивід: hello
console.log(myNumber); // Вивід: 123
console.log(myBoolean); // Вивід: true
У цьому прикладі <T>
оголошує параметр типу з назвою T
. Функція identity
приймає аргумент типу T
і повертає значення типу T
. При виклику функції ви можете явно вказати параметр типу (наприклад, identity<string>
) або дозволити TypeScript вивести його на основі типу аргументу.
Робота зі складними типами даних
Дженерики стають особливо цінними при роботі зі складними типами даних, такими як масиви, об'єкти та інтерфейси. Розгляньмо деякі поширені сценарії:
Дженерик-масиви
Ви можете використовувати дженерики для створення функцій або класів, які працюють з масивами різних типів:
function arrayToString<T>(arr: T[]): string {
return arr.join(", ");
}
let numberArray: number[] = [1, 2, 3, 4, 5];
let stringArray: string[] = ["apple", "banana", "cherry"];
console.log(arrayToString(numberArray)); // Вивід: 1, 2, 3, 4, 5
console.log(arrayToString(stringArray)); // Вивід: apple, banana, cherry
Тут функція arrayToString
приймає масив типу T[]
і повертає рядкове представлення масиву. Ця функція працює з масивами будь-якого типу, що робить її надзвичайно придатною для повторного використання.
Дженерик-об'єкти
Дженерики також можна використовувати для визначення функцій або класів, які працюють з об'єктами різної структури:
interface Person {
name: string;
age: number;
country: string; // Додано країну для глобального контексту
}
interface Product {
id: number;
name: string;
price: number;
currency: string; // Додано валюту для глобального контексту
}
function displayInfo<T extends { name: string }>(item: T): void {
console.log(`Name: ${item.name}`);
}
let person: Person = { name: "Alice", age: 30, country: "USA" };
let product: Product = { id: 1, name: "Laptop", price: 1200, currency: "USD" };
displayInfo(person); // Вивід: Name: Alice
displayInfo(product); // Вивід: Name: Laptop
У цьому прикладі функція displayInfo
приймає об'єкт типу T
, який повинен мати властивість name
типу string. Конструкція extends { name: string }
є обмеженням, яке визначає мінімальні вимоги до параметра типу T
. Це гарантує, що функція може безпечно отримати доступ до властивості name
.
Розширене використання дженериків
Дженерики в TypeScript пропонують більш розширені можливості, які дозволяють створювати ще гнучкіший та потужніший код. Розгляньмо деякі з цих можливостей:
Кілька параметрів типу
Ви можете визначати функції або класи з кількома параметрами типу:
function merge<T, U>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
interface Name {
firstName: string;
}
interface Age {
age: number;
}
const person: Name = { firstName: "Bob" };
const details: Age = { age: 42 };
const merged = merge(person, details);
console.log(merged.firstName); // Вивід: Bob
console.log(merged.age); // Вивід: 42
Функція merge
приймає два об'єкти типів T
та U
і повертає новий об'єкт, який містить властивості обох об'єктів. Це потужний спосіб комбінування даних з різних джерел.
Обмеження дженериків
Як було показано раніше, обмеження дозволяють вам звузити типи, які можна використовувати з параметром дженерик-типу. Це гарантує, що дженерик-код може безпечно працювати з указаними типами.
interface Lengthwise {
length: number;
}
function loggingIdentity<T extends Lengthwise>(arg: T): T {
console.log(arg.length);
return arg;
}
loggingIdentity([1, 2, 3]); // Вивід: 3
loggingIdentity("hello"); // Вивід: 5
// loggingIdentity(123); // Помилка: Аргумент типу 'number' не може бути присвоєний параметру типу 'Lengthwise'.
Функція loggingIdentity
приймає аргумент типу T
, який повинен мати властивість length
типу number. Це гарантує, що функція може безпечно отримати доступ до властивості length
.
Дженерик-класи
Дженерики також можна використовувати з класами:
class DataStorage<T> {
private data: T[] = [];
addItem(item: T) {
this.data.push(item);
}
removeItem(item: T) {
this.data = this.data.filter(d => d !== item);
}
getItems(): T[] {
return [...this.data];
}
}
const textStorage = new DataStorage<string>();
textStorage.addItem("apple");
textStorage.addItem("banana");
textStorage.removeItem("apple");
console.log(textStorage.getItems()); // Вивід: [ 'banana' ]
const numberStorage = new DataStorage<number>();
numberStorage.addItem(1);
numberStorage.addItem(2);
numberStorage.removeItem(1);
console.log(numberStorage.getItems()); // Вивід: [ 2 ]
Клас DataStorage
може зберігати дані будь-якого типу T
. Це дозволяє створювати повторно використовувані структури даних, які є типобезпечними.
Дженерик-інтерфейси
Дженерик-інтерфейси корисні для визначення контрактів, які можуть працювати з різними типами. Наприклад:
interface Result<T, E> {
success: boolean;
data?: T;
error?: E;
}
interface User {
id: number;
username: string;
email: string;
}
interface ErrorMessage {
code: number;
message: string;
}
function fetchUser(id: number): Result<User, ErrorMessage> {
if (id === 1) {
return { success: true, data: { id: 1, username: "john.doe", email: "john.doe@example.com" } };
} else {
return { success: false, error: { code: 404, message: "User not found" } };
}
}
const userResult = fetchUser(1);
if (userResult.success) {
console.log(userResult.data.username);
} else {
console.log(userResult.error.message);
}
Інтерфейс Result
визначає дженерик-структуру для представлення результату операції. Він може містити або дані типу T
, або помилку типу E
. Це поширений патерн для обробки асинхронних операцій або операцій, які можуть завершитися невдачею.
Утилітні типи та дженерики
TypeScript надає кілька вбудованих утилітних типів, які добре працюють з дженериками. Ці утилітні типи можуть допомогти вам потужно трансформувати та маніпулювати типами.
Partial<T>
Partial<T>
робить усі властивості типу T
необов'язковими:
interface Person {
name: string;
age: number;
}
type PartialPerson = Partial<Person>;
const partialPerson: PartialPerson = { name: "Alice" }; // Валідно
Readonly<T>
Readonly<T>
робить усі властивості типу T
доступними тільки для читання:
interface Person {
name: string;
age: number;
}
type ReadonlyPerson = Readonly<Person>;
const readonlyPerson: ReadonlyPerson = { name: "Bob", age: 42 };
// readonlyPerson.age = 43; // Помилка: Неможливо присвоїти значення 'age', оскільки це властивість тільки для читання.
Pick<T, K>
Pick<T, K>
вибирає набір властивостей K
з типу T
:
interface Person {
name: string;
age: number;
email: string;
}
type NameAndAge = Pick<Person, "name" | "age">;
const nameAndAge: NameAndAge = { name: "Charlie", age: 28 };
Omit<T, K>
Omit<T, K>
видаляє набір властивостей K
з типу T
:
interface Person {
name: string;
age: number;
email: string;
}
type PersonWithoutEmail = Omit<Person, "email">;
const personWithoutEmail: PersonWithoutEmail = { name: "David", age: 35 };
Record<K, T>
Record<K, T>
створює тип з ключами K
та значеннями типу T
:
type CountryCodes = "US" | "CA" | "UK" | "DE" | "FR" | "JP" | "CN" | "IN" | "BR" | "AU"; // Розширений список для глобального контексту
type Currency = "USD" | "CAD" | "GBP" | "EUR" | "JPY" | "CNY" | "INR" | "BRL" | "AUD"; // Розширений список для глобального контексту
type CurrencyMap = Record<CountryCodes, Currency>;
const currencyMap: CurrencyMap = {
"US": "USD",
"CA": "CAD",
"UK": "GBP",
"DE": "EUR",
"FR": "EUR",
"JP": "JPY",
"CN": "CNY",
"IN": "INR",
"BR": "BRL",
"AU": "AUD",
};
Відображені типи
Відображені типи дозволяють вам трансформувати існуючі типи, перебираючи їхні властивості. Це потужний спосіб створення нових типів на основі існуючих. Наприклад, ви можете створити тип, який робить усі властивості іншого типу доступними тільки для читання:
interface Person {
name: string;
age: number;
}
type ReadonlyPerson = {
readonly [K in keyof Person]: Person[K];
};
const readonlyPerson: ReadonlyPerson = { name: "Eve", age: 25 };
// readonlyPerson.age = 26; // Помилка: Неможливо присвоїти значення 'age', оскільки це властивість тільки для читання.
У цьому прикладі [K in keyof Person]
перебирає всі ключі інтерфейсу Person
, а Person[K]
отримує доступ до типу кожної властивості. Ключове слово readonly
робить кожну властивість доступною тільки для читання.
Умовні типи
Умовні типи дозволяють визначати типи на основі умов. Це потужний спосіб створення типів, які адаптуються до різних сценаріїв.
type NonNullable<T> = T extends null | undefined ? never : T;
type MaybeString = string | null | undefined;
type StringType = NonNullable<MaybeString>; // string
function getValue<T>(value: T): NonNullable<T> {
if (value == null) { // Обробляє і null, і undefined
throw new Error("Значення не може бути null або undefined");
}
return value as NonNullable<T>;
}
try {
const validValue = getValue("hello");
console.log(validValue.toUpperCase()); // Вивід: HELLO
const invalidValue = getValue(null); // Це спричинить помилку
console.log(invalidValue); // Цей рядок не буде виконано
} catch (error: any) {
console.error(error.message); // Вивід: Значення не може бути null або undefined
}
У цьому прикладі тип NonNullable<T>
перевіряє, чи є T
null
або undefined
. Якщо так, він повертає never
, що означає, що тип не дозволений. В іншому випадку він повертає T
. Це дозволяє створювати типи, які гарантовано не будуть нульовими.
Найкращі практики використання дженериків
Ось кілька найкращих практик, які варто пам'ятати при використанні дженериків:
- Використовуйте описові імена параметрів типу: Вибирайте імена, які чітко вказують на призначення параметра типу.
- Використовуйте обмеження для звуження типів, які можна використовувати з параметром дженерик-типу: Це гарантує, що ваш дженерик-код може безпечно працювати з указаними типами.
- Зберігайте ваш дженерик-код простим та сфокусованим: Уникайте надмірного ускладнення вашого дженерик-коду занадто великою кількістю параметрів типу або складними обмеженнями.
- Ретельно документуйте свій дженерик-код: Пояснюйте призначення параметрів типу та будь-які використані обмеження.
- Враховуйте компроміси між повторним використанням коду та типовою безпекою: Хоча дженерики можуть покращити повторне використання коду, вони також можуть ускладнити ваш код. Зважуйте переваги та недоліки перед використанням дженериків.
- Враховуйте локалізацію та глобалізацію (l10n та g11n): При роботі з даними, які потрібно відображати користувачам у різних регіонах, переконайтеся, що ваші дженерики підтримують відповідне форматування та культурні конвенції. Наприклад, форматування чисел та дат може значно відрізнятися в різних локалях.
Приклади в глобальному контексті
Розгляньмо кілька прикладів того, як дженерики можна використовувати в глобальному контексті:
Конвертація валют
interface ConversionRate {
rate: number;
fromCurrency: string;
toCurrency: string;
}
function convertCurrency<T extends ConversionRate>(amount: number, rate: T): number {
return amount * rate.rate;
}
const usdToEurRate: ConversionRate = { rate: 0.85, fromCurrency: "USD", toCurrency: "EUR" };
const amountInUSD = 100;
const amountInEUR = convertCurrency(amountInUSD, usdToEurRate);
console.log(`${amountInUSD} USD дорівнює ${amountInEUR} EUR`); // Вивід: 100 USD дорівнює 85 EUR
Форматування дати
interface DateFormatOptions {
locale: string;
options: Intl.DateTimeFormatOptions;
}
function formatDate<T extends DateFormatOptions>(date: Date, format: T): string {
return date.toLocaleDateString(format.locale, format.options);
}
const currentDate = new Date();
const usDateFormat: DateFormatOptions = { locale: "en-US", options: { year: 'numeric', month: 'long', day: 'numeric' } };
const germanDateFormat: DateFormatOptions = { locale: "de-DE", options: { year: 'numeric', month: 'long', day: 'numeric' } };
const japaneseDateFormat: DateFormatOptions = { locale: "ja-JP", options: { year: 'numeric', month: 'long', day: 'numeric' } };
console.log("Дата (США): " + formatDate(currentDate, usDateFormat));
console.log("Дата (Німеччина): " + formatDate(currentDate, germanDateFormat));
console.log("Дата (Японія): " + formatDate(currentDate, japaneseDateFormat));
Сервіс перекладу
interface Translation {
[key: string]: string; // Дозволяє динамічні ключі мови
}
interface LanguageData<T extends Translation> {
languageCode: string;
translations: T;
}
const englishTranslations: Translation = {
"hello": "Hello",
"goodbye": "Goodbye",
"welcome": "Welcome to our website!"
};
const spanishTranslations: Translation = {
"hello": "Hola",
"goodbye": "Adiós",
"welcome": "¡Bienvenido a nuestro sitio web!"
};
const frenchTranslations: Translation = {
"hello": "Bonjour",
"goodbye": "Au revoir",
"welcome": "Bienvenue sur notre site web !"
};
const languageData: LanguageData<typeof englishTranslations>[] = [
{languageCode: "en", translations: englishTranslations },
{languageCode: "es", translations: spanishTranslations },
{languageCode: "fr", translations: frenchTranslations}
];
function translate<T extends Translation>(key: string, languageCode: string, languageData: LanguageData<T>[]): string {
const lang = languageData.find(lang => lang.languageCode === languageCode);
if (!lang) {
return `Переклад для ${key} мовою ${languageCode} не знайдено.`;
}
return lang.translations[key] || `Переклад для ${key} не знайдено.`;
}
console.log(translate("hello", "en", languageData)); // Вивід: Hello
console.log(translate("hello", "es", languageData)); // Вивід: Hola
console.log(translate("welcome", "fr", languageData)); // Вивід: Bienvenue sur notre site web !
console.log(translate("missingKey", "de", languageData)); // Вивід: Переклад для missingKey мовою de не знайдено.
Висновок
Дженерики в TypeScript — це потужний інструмент для написання повторно використовуваного, типобезпечного коду, який може працювати зі складними типами даних. Розуміючи базовий синтаксис, розширені можливості та найкращі практики використання дженериків, ви можете значно покращити якість та підтримуваність ваших застосунків на TypeScript. При розробці застосунків для глобальної аудиторії дженерики можуть допомогти вам обробляти різноманітні формати даних та культурні конвенції, забезпечуючи бездоганний користувацький досвід для всіх.